Design - Theming
August 17, 2026
Every MoreForm, MoreUserControl, and More* control gets its colors from one place: ThemeManager (OneMore/UI/ThemeManager.cs). This doc covers that mechanism — how a theme is resolved, how it's applied to a form's control tree, and the pattern individual controls follow to opt in. It does not cover how OneNote itself remaps page content colors between light and dark canvas, or the raw Office theme registry values — that's already written up in TechNote - Colors. This doc is scoped entirely to OneMore's own dialog/control theming layer, which shares no code with it. See also Design - UI Layer for MoreForm/MoreUserControl and the modal/modeless mechanics this builds on.
This layer exists because OneMore renders its own WinForms UI on top of an Office ribbon whose theme (Colorful/Dark Gray/Black/White/System) is set independently of Windows' own light/dark setting, and neither one is visible to dllhost.exe through any manifest or API OneMore doesn't call explicitly. A OneMore dialog has to go decide, for itself, whether it should look light or dark.
Architecture
ThemeManager (OneMore/UI/ThemeManager.cs) is a lazy static singleton:
public static ThemeManager Instance => instance ??= new ThemeManager();
There's no DI container involved — MoreForm and MoreUserControl both cache ThemeManager.Instance into a protected readonly ThemeManager manager field in their constructors, and individual More* controls do the same. The instance loads its color table once, the first time anything touches Instance, and caches it for the life of the process.
ThemeManager doubles as the palette itself — there's no separate Theme class. It holds a flat Dictionary<string, Color> Colors plus a public bool DarkMode { get; private set; } flag, and exposes GetColor(string key) for controls to pull named colors out of it.
ThemeMode
internal enum ThemeMode { System, Light, Dark, User }
Only System, Light, and Dark are reachable through the product's own UI — GeneralSheet's theme dropdown populates exactly those three. User is defined but not wired to any settings-sheet option; see "Custom themes" below for how a user theme actually gets applied instead.
Resolving the active theme
LoadColors(int modeIndex = -1) runs this precedence, in order:
- A custom theme file, if present, always wins — regardless of the ThemeMode setting. If OneMoreTheme.json exists under the add-in's AppData folder (PathHelper.GetAppDataPath()), it's deserialized verbatim and everything below is skipped.
- Otherwise the mode is either the modeIndex argument (when ≥ 0) or the persisted Theme setting read from SettingsProvider.
- DarkMode is computed as:
DarkMode = !IsDesignTime &&
(mode == ThemeMode.Dark ||
(mode == ThemeMode.System && Office.IsBlackThemeEnabled(true)));
Office.IsBlackThemeEnabled (OneMore/Helpers/Office/Office.cs) reads the Office UI Theme/Theme registry value under HKCU\Software\Microsoft\Office\{ver}\Common; if Office itself is set to "System" (6), it falls through to the Windows personalization key (HKCU\...\Themes\Personalize\AppsUseLightTheme). The ignorePage: true argument ThemeManager always passes means this check deliberately ignores OneNote's own per-page "Switch Background" light override — dialog theming tracks Office's chrome theme, not the currently open page.
- Colors load from one of two embedded JSON resources —
OneMore/UI/DarkTheme.json or LightTheme.json — via a small custom JsonConverter<Color> that accepts either a #RRGGBB hex string or a known System.Drawing color name.
IsDesignTime (checked via LicenseManager.UsageMode / process name) is consulted here and throughout the theming code specifically to make sure nothing ever assigns BackColor/ForeColor while a form is open in the Visual Studio designer — a plain property assignment there gets baked permanently into the generated Designer.cs, because ShouldSerializeXxx conventions aren't honored for Control.BackColor/ForeColor overrides in .NET Framework. Skipping the assignment at design time is what prevents a wrong-for-runtime color from getting hardcoded into a control's Designer.cs the next time it's opened in the designer.
Custom themes
There's no in-app editor. A user (or a future settings sheet) drops a JSON file — same shape as DarkTheme.json/LightTheme.json, a DarkMode bool plus a Colors map — at PathHelper.GetAppDataPath()\OneMoreTheme.json, and LoadColors picks it up unconditionally on next load. ThemeManager.HasCustomTheme() is just an File.Exists check other code can use to detect this state.
No live theme switching
ThemeManager loads its color table once and caches it for the process lifetime. There is no SystemEvents.UserPreferenceChanged subscription, no WM_SETTINGCHANGE handling, and no registry of currently-open forms anywhere in the theming code. A Windows or Office theme change while OneMore is running has no effect on already-open dialogs, or on newly opened ones, until the dllhost.exe COM surrogate process restarts — or until the user explicitly changes the theme dropdown in GeneralSheet, which calls ThemeManager.Instance.LoadColors(themeBox.SelectedIndex) directly. Anyone building a new dialog can treat "the theme" as a fixed input for the lifetime of that dialog; there's no live-update case to handle.
Applying a theme to a form
Two independent recursive tree walks run when a MoreForm loads, triggered from manager.InitializeTheme(this) in MoreForm.OnLoad:
- Colorize(Control control) — assigns BackColor/ForeColor directly. Parent first, then recurses into control.Controls. For most controls it just does control.BackColor = GetColor(control.BackColor) (mapping the design-time placeholder color to its themed equivalent), but it dispatches to IThemedControl.ApplyTheme(this) when a control implements that interface, and has special-cased branches for ComboBox, Label, PictureBox, ListView items, StatusStrip items, and DateTimePicker. It deliberately skips ListView and any ToolStrip/MenuStrip other than StatusStrip — those own their coloring entirely themselves (see below).
- LoadControls(Control.ControlCollection controls) — a local recursive function inside MoreForm.OnLoad that calls ((ILoadControl)child).OnLoad() on every descendant that implements ILoadControl, again parent-before-children, with no type filtering beyond the interface check. This is the hook simple controls that don't otherwise support an OnLoad (Button, Label, ...) use to do one-time themed setup at load — resolving their own colors, swapping an icon for its dark-mode variant, and so on.
Only MoreForm.OnLoad runs the ILoadControl walk. MoreUserControl.OnLoad calls manager.InitializeTheme(this) for its own Colorize pass but does not walk ILoadControl children itself — a MoreUserControl hosted inside a MoreForm gets its ILoadControl descendants themed by the host form's single top-level walk, not by the user control independently. SheetBase (the settings-sheet base class) is itself ILoadControl specifically so settings sheets hosted inside a container dialog participate correctly in that outer walk.
The two control-side contracts
IThemedControl (OneMore/UI/IThemedControl.cs) — for controls that need to resolve their own colors, optionally against per-instance overrides:
internal interface IThemedControl
{
string ThemedBack { get; set; } // e.g. "ErrorText" for a validation field
string ThemedFore { get; set; }
void ApplyTheme(ThemeManager manager);
}
Colorize calls ApplyTheme(this) on any control that implements it, instead of doing its own default color assignment.
ILoadControl (OneMore/UI/ILoadControl.cs) — for controls (like Button and Label) that don't otherwise expose an extensibility point for one-time load logic:
internal interface ILoadControl
{
Control.ControlCollection Controls { get; }
void OnLoad(); // declared as: void ILoadControl.OnLoad() { }
}
The two are not mutually exclusive, and a control can route all of its real logic through one while leaving the other a near no-op stub just to satisfy Colorize's dispatch — MoreDataGridView implements both, with ApplyTheme doing nothing (// let OnLoad handle it) and all real theming logic in ILoadControl.OnLoad().
How individual More* controls theme themselves
There's no single required pattern — each control does whatever its underlying WinForms base class needs:
- MoreButton — ILoadControl only. Fully owner-drawn
(ControlStyles.UserPaint); OnLoad() resolves BackColor/ForeColor respecting ThemedBack/ThemedFore, and if StylizeImage is set, runs its Image through an inverting ImageEditor when manager.DarkMode. OnPaint picks background/border colors directly from manager based on hover/pressed/focus state. - MoreTextBox — ILoadControl only. OnLoad() sets ForeColor/
BackColor from ThemedFore/ThemedBack (falling back to "WindowText"/"Window"), and re-runs that same logic from OnEnabledChanged so toggling Enabled re-themes the control live (grayed background/text) without a full reload. - MoreListView — implements neither interface, and is
explicitly skipped by Colorize. It's fully owner-drawn instead (OwnerDraw = true), pulling colors directly from manager.GetColor(...) in its DrawColumnHeader/DrawItem/DrawSubItem handlers, with configurable SelectedBackColorKey/SelectedForeColorKey properties (default "Highlight"/"HighlightText"). It also pushes the native LVM_SETBKCOLOR message on handle creation, since WinForms ListView doesn't forward BackColor to the blank area below the last row. - MoreDataGridView — both interfaces; ApplyTheme is a deliberate
no-op, ILoadControl.OnLoad() does the real work (BackgroundColor, ForeColor, GridColor from "WindowFrame", header/row/cell DefaultCellStyle colors, EnableHeadersVisualStyles = false). - MoreMenuStrip / MoreToolStrip — ILoadControl only, and also
skipped by Colorize (any ToolStrip/MenuStrip other than StatusStrip). They instead supply a custom ToolStripProfessionalRenderer at construction, built on ThemedColorTable (OneMore/UI/ThemedColorTable.cs), a ProfessionalColorTable override that pulls "MenuBar", "MenuHighlight", "MenuMargin", "MenuSeparator", "WindowFrame" from ThemeManager.Instance. MoreMenuItem/MoreToolStripButton/ MoreSplitButton override their Image setter to auto-invert icons through ImageEditor whenever manager.DarkMode — the general pattern for icon-per-theme without maintaining a separate dark asset for most toolbar/menu glyphs.
OnThemeChange()
public virtual void OnThemeChange() { } // MoreForm and MoreUserControl
Called from ThemeManager.InitializeTheme(ContainerControl), but only when DarkMode is true — light-themed dialogs never get it invoked. It's a load-time hook, not a live-update hook (see "No live theme switching" above): it fires exactly once, from OnLoad, before Colorize runs. It exists for the case a static color assignment can't cover — controls holding cached, expensive-to-recreate Brush/Pen/ Image state for owner-drawn rendering. The one real override in the codebase, SearchResultsCardView.OnThemeChange, reallocates its cached SolidBrush/Pen objects from manager.GetColor(...) for its owner-drawn card view.
Beyond WinForms
- Ribbon icons — AddinRibbon.GetRibbonImage(string imageName),
bound as the ribbon XML's loadImage callback, checks Office.IsBlackThemeEnabled(true) and, if dark, looks for a resource named $"Dark{imageName}" before falling back to the plain imageName. Unlike the toolstrip/menu pattern above, ribbon icons use explicitly authored dark variants, not runtime inversion — there's no single owner-drawn surface to hook into for the ribbon. - OneNote page content — not touched by ThemeManager at all. Any
page-color or ink-color remapping for dark/light canvas is a distinct, page-content-focused concern handled elsewhere (PageColorCommand, PageColors, ColorExtensions) and documented in TechNote - Colors. - WebViewDialog — no dark-mode CSS/JS injection. WebView2 content
renders exactly as authored; ThemeManager has no involvement.
References
- OneMore/UI/ThemeManager.cs — singleton, ThemeMode, LoadColors,
InitializeTheme, Colorize, HasCustomTheme - OneMore/UI/IThemedControl.cs, ILoadControl.cs
- OneMore/UI/DarkTheme.json, LightTheme.json — built-in palettes
- OneMore/UI/ThemedColorTable.cs — ProfessionalColorTable for
menu/toolstrip renderers - OneMore/UI/MoreButton.cs, MoreTextBox.cs, MoreListView.cs,
MoreDataGridView.cs, MoreMenuStrip.cs, MoreToolStrip.cs - OneMore/Helpers/Office/Office.cs — IsBlackThemeEnabled,
SystemDefaultDarkMode, DarkModeLightsOn - OneMore/Commands/Settings/GeneralSheet.cs — the only in-app theme
picker (System/Light/Dark) - OneMore/Ribbon/AddinRibbon.cs — GetRibbonImage, dark ribbon-icon
fallback convention - TechNote - Colors —
OneNote's own page/ink color remapping; Office theme registry values - Design - UI Layer —
MoreForm/MoreUserControl, modal/modeless mechanics this builds on
Note: OneMoreCalendar, the companion tray app, has its own separate theming stack (ThemeProvider, ThemedForm, ThemedUserControl) with a working in-app theme editor — structurally similar but independent code, out of scope here.
#omwiki #omdeveloper #omdesign
© 2021 Steven M Cohn. All rights reserved.
Please consider a sponsorship or one-time donation to support ongoing development
Created with OneNote.